iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 19 篇

Day 14(上)|SLI、SLO、SLA:先量承諾,再談百分比

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

結論先說:SLI 是量測結果,SLO 是團隊的目標,SLA 是對外承諾;三者混在一起,會讓 dashboard、排程與合約各說各話。

Day 13 處理的是「一個元件倒下之後」怎麼切換;今天往回問一個更根本的問題:切換做得再好,要拿什麼指標證明系統真的可靠?99.9% 本身沒有意義。先問「誰在什麼情況下,得到什麼結果?」才知道分子、分母與門檻怎麼定。

今天不會替這個 Lab 宣告任何 production SLO,也不會執行 DIY。下面的程式、資料與查詢,是讓你在自己的環境中把承諾寫成可檢查規格;數字必須在你有流量、產品需求與 owner 之後再決定。

① 先把三個縮寫放到正確的位置

很多團隊第一次討論 SLO,會在同一場會議裡輪流說出三句話:

我們的 uptime 要 99.9%。
客戶說不能慢。
Prometheus 有一張 dashboard。

三句都可能是真的,卻還不能組成可靠性目標。

uptime 沒說明量測單位。

「不能慢」沒說明哪一種使用者操作。

dashboard 則只是資料顯示的位置,不會自動替你選擇服務承諾。

先把責任拆開,討論才不會變成百分比選美。

名詞 真正回答的問題 典型產物 誰要一起負責
SLI 我們用什麼可重複規則量測使用者經驗? good event、分子、分母、資料來源 工程與產品
SLO 在一段時間內,我們願意承擔多少失敗? 目標、window、error budget、行動 產品、工程與服務 owner
SLA 對外要負什麼可執行的商務或法律責任? 合約條款、適用範圍、補償、除外條款 法務、商務與服務 owner

SLI 不等於一條 PromQL。

PromQL 是把 SLI 的資料定義算出來的工具;如果 good event 定義錯了,查詢寫得再漂亮也只是在快速地得到錯答案。

SLO 也不是把某個百分比貼到 Grafana。

它是一個取捨:為了維持某個使用者體驗,團隊願意留多少改動速度,又願意接受多少失敗。

SLA 的語氣最嚴肅。

一旦放進對外承諾,例外條件、時區、維護窗口、計算方式和補償流程都要能被第三方理解。Lab 裡的 99.5% 不應直接複製到合約。

「99.9%」這個數字也容易騙人。技術上它等於每月約 43.8 分鐘停機,但實際業務影響取決於那些分鐘是集中發生還是分散、定義中是否排除了規劃維護與第三方依賴故障。2025 年 10 月的 AWS DynamoDB 事件就是這種落差的示範:標稱 SLA 對應的預期停機遠低於一次 DNS 競態條件實際造成的中斷時間,根本原因不是程式碼品質,而是 SLA 定義的邊界沒把隱藏依賴(連 DNS 都會故障)算進去。

Google SRE 在 Shakespeare 搜尋服務的做法則說明分母與門檻要先於百分比決定:他們不是隨便選一個數字,而是針對不同操作類型分別設定目標,例如「99% 的 Get RPC 在 100 毫秒內完成」,再依吞吐量型與延遲敏感型使用者拆出不同門檻。分類錯了,再漂亮的百分比都會誤導判斷。

「99.9%」還有一個更容易被忽略的陷阱: 它在多個相依服務疊加之後不是還原成同一個數字, 而是會相乘變小。資安研究者 Troy Hunt 拆解過 Azure 的 SLA 條款: 單一服務的 99.9% 承諾, 換算下來每月最多容許約 45 分鐘停機; 但如果一個系統疊了三層各自承諾 99.9% 的相依服務(例如 API + 資料庫 + 訊息佇列), 組合後的實際可用性只剩 0.999 × 0.999 × 0.999 ≈ 99.7%, 換算成停機時間直接變成三倍。原因很單純: 只要任何一層掛掉, 使用者感受到的就是整體失敗, 而 SLA 並不承諾「三層剛好同時故障」, 所以每一層各自允許的停機視窗是可以疊加的, 不是共用同一份預算。更值得注意的是補償結構本身也有落差: Azure 的服務額度是分級計算, 停機 45 分鐘到將近 8 小時只退該項服務帳單的 10%, 超過 8 小時才退到 25%, 而且只退「出問題的那項服務」的帳單, 不是整個帳戶。這代表 SLA 條款保護的是供應商的責任上限, 不是使用者的實際業務損失——把這個落差寫進合約前, 產品與法務都該先看過真實數字, 而不是只看「99.9%」這四個字。Troy Hunt:The Cloud Never Goes Down

這個「相乘不是相加」的直覺, 對 Day 14 的 Lab 也有直接影響。/ask 這條路徑至少疊了 API 層、retrieval 層與 LLM provider 層三個環節, 若日後真的要對外承諾 SLA, 不能只看單一環節的可用性, 而要先問: 「使用者旅程完整跑一次, 需要經過幾個各自可能失敗的環節?」再回頭反推組合後的可用性上限。

把 Troy Hunt 拆解出來的分級補償結構具體攤開,會更容易看出「條款保護的是誰」這句話的意思:

月停機時間 對應可用性區間 服務額度(該項服務帳單退款比例)
少於 45 分鐘 ≥ 99.9% 無退款,視為達標
45 分鐘 ~ 約 8 小時 遠低於 99.9%,但供應商仍只視為輕微違約 10%
超過 8 小時 嚴重違約 25%

這張表最不對稱的地方,在於「45 分鐘」與「8 小時」之間橫跨近十倍的落差,卻共用同一個 10% 退款級距——停機 50 分鐘與 7 小時 50 分鐘的業務損失可能天差地遠,供應商的補償責任卻完全相同。這正是「SLA 條款是供應商責任上限,不是使用者損失的保險」最具體的數字證據:條款把供應商的財務曝險鎖在一個可預測的天花板內,不會隨使用者實際損失等比例增加。

SLI ≠ SLO ≠ SLA:三個常見的混用場景

光把三個定義背下來還不夠,因為現實中的混用通常不是「完全搞錯定義」,而是把三者的責任邊界悄悄挪動,挪到誰都沒注意到的地方。三個最常見的場景,值得先攤開來看。

第一種混用是「把 SLO 直接當 KPI 發獎金」。SLO 的本質是團隊自己選定、隨時可以因為使用者需求改變而重新協商的內部目標;一旦把它綁上績效考核,團隊會有理性誘因把目標訂得容易達成,而不是訂得對使用者最有意義。Google SRE Book 特別提醒這個陷阱:SLO 應該被當作「工程決策的輸入」,而不是「用來評分某個團隊做得好不好的尺」——一旦變成後者,所有人的行為都會朝著「讓數字好看」而非「讓使用者滿意」偏移。

第二種混用是「把 SLA 的措辞直接套進內部 SLO」。SLA 條款要能被法務、對方公司、第三方稽核員理解,因此它傾向使用保守、模糊、留有大量除外條款的語言(「合理範圍內」「排除不可抗力」)。這種語言放進工程內部的 SLO 討論會製造麻煩:工程師需要知道「denominator 精確是什麼」,而不是「在合理範圍內大概是什麼」。把 SLA 條款複製貼上當 SLO 用,等於讓一份寫給仲裁庭看的文件去指導凌晨三點的 on-call 決策。

第三種混用是「SLI 隨著 incident 而變動」。這種情況最隱蔽:資料本身沒有造假,只是每次 incident 後續有人「順手」把某類失敗挪出分母,讓下次同樣的失敗不再影響數字。單次調整可能有充分理由(例如確實發現某類流量不該計入),但如果沒有版本紀錄與 review 流程,六個月後回頭看,會出現「同一個 99.5%,實際涵蓋的失敗範圍完全不同」的情況——這也是本文稍後在 ⑤ 段要求為 SLI 加上 sli_definition_version 的原因。

這三種混用有一個共同特徵:沒有一步是明顯錯誤,都是「看起來合理的小調整」。正因如此,SLI/SLO/SLA 的責任分工不能只講一次就結束,而要變成一份能被隨時拿出來對照的規格——這正是 Day 14 後半段要建立的東西。

一個系統可能需要不止一組目標

前面提到 Google 在 Shakespeare 搜尋服務上,針對不同操作類型分別設定了時間目標,這個決定背後其實還有另一層更少被提到的細節:Google 不只是拆分「操作類型」,還進一步拆分了「使用者類型」。他們區分出兩種截然不同的使用情境——一種是延遲敏感型(latency-sensitive)使用者,這類人期待互動式介面在極短時間內回應,寧可犧牲少量吞吐量也要換取即時性;另一種是吞吐量型(throughput-oriented)使用者,例如批次分析或背景索引作業,這類流量對單筆請求的延遲不敏感,但在意整體處理速度與資源效率。如果把這兩種使用者混在同一組 SLO 底下,會發生一個常見的失衡:為了滿足延遲敏感型使用者,系統被迫用犧牲吞吐量的方式換取低延遲(例如降低 batch size、增加並行度),結果讓吞吐量型使用者的整體處理時間被拖慢;反過來,如果優化方向偏向吞吐量,延遲敏感型使用者又會感受到明顯變慢。兩種使用者的最佳化方向本質上互相拉扯,用同一套目標服務兩種人,等於讓系統永遠處在「兩邊都不夠好」的妥協點上。

這個教訓可以直接對照到 /ask 這條路徑。政策問答場景裡,同樣可能同時存在互動式使用者(員工在對話介面裡即時發問,期待秒級回應)與批次使用者(HR 系統夜間跑一批政策合規檢查,對單筆延遲毫不在意,只在意整批能不能在維護窗口內跑完)。如果只用一組 SLO 涵蓋這兩種流量,很容易在某次容量規劃討論中卡住:工程團隊想知道「為了達到 P95 2 秒的目標,需要多少 GPU 或 provider 配額」,卻沒發現這個目標其實是被夜間批次流量的尖峰吞吐量拉高的,真正的互動式使用者體驗遠比目標寬鬆得多。多軌 SLO 不是把問題複雜化,而是承認「不同使用者對可靠性的期待本來就不一樣」,讓每一組目標對應一種真實可辨識的使用情境,而不是用一個平均數字掩蓋兩種矛盾的需求。

拆開之後,兩軌的門檻設計原則完全不同,值得並排寫清楚,而不是各自憑直覺喊一個數字:

維度 互動式旅程(員工即時發問) 批次旅程(HR 夜間合規檢查)
量測重點 單筆延遲(使用者正在等) 整批完成時間(有沒有人在等特定一筆)
典型門檻 P95 在 2 秒內完成 整批(例如 5,000 筆)在維護窗口(例如 4 小時)內全數完成
失敗定義 單筆超過門檻 整批超過窗口,或窗口內完成率低於某比例
容量規劃輸入 尖峰併發請求數 批次總量與可用窗口長度
過度樂觀的陷阱 用平均延遲掩蓋長尾等待 用「批次通常會完成」掩蓋窗口邊緣偶爾溢出的情況

兩軌一旦拆開,容量規劃的問題也會變得可以分開回答:「互動式旅程需要多少 GPU 配額才能撐住尖峰併發」與「批次旅程的整批窗口要抓多長,才能容納最壞情況下的重試與降級」,是兩個各自獨立、可以分開估算的問題,混在一起反而誰都算不準。

實務上要不要一開始就拆多軌 SLO,取決於流量規模:如果目前 /ask 的批次流量占比極小、或者根本還不存在,硬拆兩組目標只是在製造管理負擔,先用單一 SLO、但在 event 裡保留 journey_type 這類可拆解的 label(前面第 ② 段已經提過同樣的建議),等批次流量真的出現規模、且與互動式流量的優化方向明顯衝突時,再回頭拆分即可。

拆了多條 SLI 之後,會有人想再把它們揉回一個分數

拆出多軌 SLO 之後,幾乎必然會有人提議把這幾條 SLI 加權平均成一個「整體健康分數」給主管看。這個提議看似合理,卻悄悄丟掉了拆分多軌 SLO 原本想保留的資訊:旅程 C(高風險轉真人)只佔整體流量 0.5%,若照流量比例加權,這條旅程無論表現多差,對整體分數的影響永遠微乎其微——稀釋問題只是換了個位置在「聚合」這一步重新發生,而不是被解決。

Google SRE Workbook 傾向讓每條 SLI 保留獨立的分子分母,需要總覽時用並排燈號而非加權平均。第 ⑫ 段會用一個具體的「主管要一個數字」場景,把這個問題與折衷做法攤開細講,這裡先點出風險。

② 從使用者旅程開始,而不是從 HTTP status 開始

Day 7 已經把 technical success 與 semantic success 分開。

Day 14 要把那個判斷落在每一筆事件上。

以公司政策問答的 /ask 為例,使用者不是來要求一個 200。

他想知道自己能不能遠端工作、假單應該怎麼送,或系統是否已經把問題交給真人。

可先把最小旅程寫成這樣:

登入的使用者送出問題
  ↓
服務驗證請求並建立 request_id
  ↓
Retriever 取得可用政策內容,或誠實回報資料不足
  ↓
模型與 validator 產生符合 response contract 的結果
  ↓
使用者在延遲門檻內收到結果或明確下一步

這個旅程會產生幾種長得很像、意義卻不同的事件。

情境 HTTP workflow 狀態 對使用者是否可用 availability SLI 的初步判斷
有來源支持的政策答案 200 completed 是 good event
找不到文件,清楚回覆資料不足 200 insufficient_context 通常是 good event,前提是這是產品承諾
高風險問題送人工處理 202 或 200 requires_human_review 視契約而定 先由產品定義
provider timeout 504 timed_out 否 bad event
回 200,卻缺少必要欄位 200 contract_invalid 否 bad event
使用者送壞 JSON 422 invalid_request 不是服務失效 通常不進 availability 分母
已登入使用者被系統錯誤拒絕 403 authorization_error 否 多半應進分母

最後兩列是常見陷阱。

所有 4xx 都排除,看起來能讓數字很好看;但若權限服務故障,使用者大量收到 403,這正是可用性問題。

反過來,把每一個格式錯誤都算進分母,則會讓惡意流量或測試腳本替真實使用者決定 SLO。

結論不是背一張 status-code 對照表,而是為每個類型寫出理由、資料來源與 owner。

Netflix 在 2012 年 AWS 停機後重新檢視衡量方式。團隊原本監控各個微服務的 uptime,但發現即使內部服務故障,使用者透過 fallback 可能完全感受不到。Netflix 改用 Playback Starts Per Second (SPS),量測使用者按下播放鍵後成功開始播放的比例。這比確認某個 process 是否存活,更接近使用者能否完成任務。Netflix 甚至在「紙牌屋」首播的最高流量時段故意執行 Chaos Monkey(隨機關閉伺服器),系統自癒且沒有影響使用者。這也是本文採用使用者結果作為 SLI 的原因。

值得注意的是,Netflix 選擇「播放開始」而非「整段影片播放完畢」作為量測點,這個切點本身就是一次工程取捨的結果。「播完整部影片」聽起來更貼近「使用者真的滿意」,但分母會被大量與服務品質無關的因素污染——使用者中途離開去接電話、切換到別的 app、單純看到一半沒興趣了,這些都會被誤記為「失敗」。「播放開始」則是一個服務端可以確實控制、且與使用者意圖高度相關的邊界:使用者按下播放鍵,代表他已經做出選擇;接下來影片串流是否成功建立,才是屬於服務可靠性的問題。這個取捨提醒我們:定義 SLI 的量測點時,要找「使用者意圖已確定、但結果仍由系統決定」的那個瞬間,而不是「使用者主觀滿意與否」的整個過程——後者屬於產品分析與 evaluation 的範疇,不是 availability SLI 該扛的責任。

一個服務可能需要不止一條 SLI

/ask 看起來是單一端點,但如果把使用者旅程攤開,會發現裡面藏著至少三種不同的「使用者意圖」,各自的成功定義不盡相同:

旅程 A:一般政策問答
  使用者想要一個有來源支持的答案
  → good = completed + 有 citation + 在門檻內

旅程 B:追問/多輪對話
  使用者延續前一輪脈絡繼續問
  → good 除了上面條件,還要求 context 沒有遺失

旅程 C:高風險問題(例如涉及法遵、資遣)
  使用者需要的是「正確轉交给真人」而非「AI 自己回答」
  → good = 正確辨識風險並在門檻內轉交,而不是模型自己生成答案

如果把這三種旅程壓進同一個 availability SLI,會發生兩種常見的失真。一種是「旅程 C 被旅程 A 的量大稀釋」:假設每天 10,000 筆問答裡只有 50 筆屬於高風險轉真人,即使這 50 筆全部誤判、AI 自己回答了本該轉人工的問題,整體 availability 依然會停留在 99% 以上,dashboard 完全看不出這個對業務影響可能最大的失敗類別正在發生。另一種是反過來,把「多輪對話中 context 遺失」與「單輪問答成功」用同一套判準衡量,會讓多輪對話的細微退化被單輪流量的高成功率蓋過去。

實務上不必一開始就拆成三條獨立的 Prometheus metric family,但至少要在 event 中保留能夠拆解的 label(例如 journey_type),讓未來需要拆分時,資料已經在那裡,而不是等發現問題才回頭補埋點。這正好呼應 Day 2 建立 Observability Stack 時的立場:先讓資料結構留有餘裕,而不是急著把每個指標都做成一條漂亮的折線圖。

③ 先定義 event contract,監控才能不靠猜

一個實用的 SLI 不需要一開始就有十幾個 label。

先讓每個完成中的 /ask 都能輸出一個低基數事件即可。

{
  "event_name": "ask_completed",
  "request_id": "req_01J...",
  "workflow_status": "completed",
  "technical_status": "success",
  "response_contract_valid": true,
  "valid_request": true,
  "latency_ms": 840,
  "sli_eligible": true,
  "sli_result": "good",
  "prompt_version": "policy-qa-v3",
  "service_version": "2026.09.21"
}

這裡刻意同時保留 workflow_status 與 sli_result。

前者協助調查發生了什麼;後者是針對特定 SLI 的判斷。不要要求 dashboard 在查詢時臨時猜測 completed、insufficient_context 和 requires_human_review 哪些算成功。

request_id、prompt 版本與模型識別是高基數或高變化欄位。

它們適合 logs 與 traces,不應放進 Prometheus 的 metric labels。

把 request_id 做成 label 的後果不是「可以查得很細」,而是每一筆請求都可能製造一條新 time series。Prometheus 會被用來處理最不適合它的資料,值班時也會先感受到記憶體帳單。

為什麼高基數 label 不是「品味問題」,而是機制問題

這句警告值得展開,因為初次踩到這個坑的人通常會覺得「不過是多存一點資料,有什麼大不了」。Prometheus 的儲存模型(TSDB)以「每一組唯一的 label 組合」建立一條獨立的 time series,每條 time series 各自維護記憶體中的 chunk 與索引。假設 ask_sli_events_total 只有 route、sli_result、sli_definition_version 三個低基數 label,實際存在的 time series 數量頂多是幾十條——route 可能的值有限、sli_result 只有 good/bad/excluded。但只要多加一個 request_id 這種近乎全域唯一的 label,time series 數量會直接跳到「歷史上出現過的請求數」這個量級,而且這些 time series 一旦建立就不會消失,只會隨著時間持續累積在記憶體與磁碟索引裡,直到超過 retention 才被清除。

這不是「查詢會變慢」這種可以忍受的效能問題,而是記憶體用量與請求量直接掛鉤的結構性風險:平常流量正常時可能沒事,一旦遇到流量尖峰或者某個異常客戶端瘋狂重試(正是 Day 12 討論過的 retry storm),time series 數量會跟著洪水般暴增,Prometheus 進程本身反而先於被監控的服務發生 OOM。用來偵測問題的系統,先於它要監測的系統倒下——這和 Meta 2021 年那次 backbone 事故裡「監控依賴同一條被監控的網路」是同一種結構性失誤,只是規模與領域不同:一個是網路拓樸層級的共病,一個是可觀測性系統自己的資料模型設計錯誤。

所以「高基數欄位放 logs/traces、低基數欄位放 metrics」不是風格建議,而是尊重每個系統原本設計要處理的資料形狀:metrics 系統為「少量、長期累積、拿來算 rate 與 aggregate」而生;logs/traces 系統則為「大量、短期查詢、拿來鑑識單一事件」而生。把資料放錯系統,兩邊都會用最貴的方式做最不適合的事。

可把資料放到正確層次:

Prometheus metrics
├─ sli_eligible
├─ sli_result
├─ route
├─ deployment
└─ model_route(僅固定、有限的 routing 類別)

Logs / traces
├─ request_id
├─ trace_id
├─ user or tenant identifier(依隱私政策處理)
├─ prompt_version
├─ retrieval document identifiers
└─ validator explanation

這不是犧牲可觀測性。

它是把「趨勢告警」和「單筆鑑識」分給最合適的訊號。

一個真實發生過的基數爆炸:加一個 label,拖垮整套監控

上面講的「Prometheus 進程先於被監控服務 OOM」不是紙上談兵的假設,業界已經有具體案例可以對照。有團隊曾在一個請求計數器上加了一個 user_id label,用意單純:想按使用者追蹤流量,方便日後查「這個使用者最近打了幾次 API」。系統當時有百萬等級的活躍使用者,這個決定在三個月內悄悄產生了超過五百萬條獨立 time series。

危險的地方在於,這個事件一開始完全沒有觸發任何告警——metrics 本身看起來「正常」,數字照樣在跳動,dashboard 照樣能畫出線。真正的傷害是隨著 time series 數量持續累積、記憶體與索引用量跟著單調上升,Prometheus 進程逐步被推向極限,最終被 OOM killer 強制關閉。這一關閉,連帶弄壞了整個監控系統本身:Grafana dashboard 全部變成空白,Prometheus 無法維持穩定啟動狀態,原本毫秒級的查詢延遲到 30 秒甚至完全無回應。事後恢復需要開戰情室、回滾程式碼、外加大量的手動修復工作。

這個案例值得放進 Day 14,不只是因為它剛好示範了「加一個 label 就能拖垮監控」這句話不是誇飾,而是它精準對照了本文一直在強調的兩件事:第一,cardinality 爆炸是無聲的——在傷害變得明顯之前,它已經在背景裡持續累積,沒有錯誤碼、沒有例外、也沒有任何一行 log 主動告訴你「這個決定會出事」;第二,用來偵測問題的監控系統,可能先於它要監測的目標系統倒下——這正是本文前面提到的 Meta 2021 backbone 事故(監控依賴同一條被監控的網路)在可觀測性資料模型層級的翻版,只是這次共病的對象換成了 Prometheus 自己的 TSDB 設計,而不是網路拓樸。

回頭對照本文 event contract 的兩份清單:sli_eligible、sli_result、route、sli_definition_version 這幾個欄位之所以安全,是因為它們的可能值集合天生有界——sli_result 永遠只會是 good/bad/excluded 三選一,不會隨著使用者數量或請求量成長而膨脹。user_id(或 request_id)之所以危險,正是因為它的可能值集合等於「曾經出現過的使用者(或請求)數量」,這個數字只會隨著產品成長單調遞增,永遠沒有上限。把這種欄位放進 label,不是「多存一點資料」的小決定,而是把 Prometheus 的記憶體用量與業務成長直接掛鉤——業務越成功,監控系統死得越快。OpenObserve:The Prometheus Cardinality Bomb

event contract 在程式碼裡落地的位置

前面的 JSON 只是概念上的 event 長相,實際串進 Day 2 那套 FastAPI + Prometheus 的 Lab 時,這個 event 應該在 request 生命週期的哪一個時間點被建立、又該在哪裡分岔成「一份給 log、一份給 metric」,值得具體走一遍。概念上的骨架大致如下:

from dataclasses import asdict
from time import perf_counter

import structlog
from fastapi import APIRouter, Request

logger = structlog.get_logger()
router = APIRouter()


@router.post("/ask")
async def ask(request: Request, payload: AskRequest):
    started_at = perf_counter()
    request_id = request.state.request_id  # 由中介層在最上游建立

    try:
        result = await run_ask_workflow(payload, request_id=request_id)
        event = build_ask_event(
            request_id=request_id,
            payload=payload,
            result=result,
            latency_ms=int((perf_counter() - started_at) * 1000),
        )
    except AskWorkflowError as exc:
        event = build_ask_event_from_error(
            request_id=request_id,
            payload=payload,
            error=exc,
            latency_ms=int((perf_counter() - started_at) * 1000),
        )

    # 給 logs / traces:完整事件,含高基數欄位,供單筆鑑識
    logger.info("ask_completed", **asdict(event))

    # 給 Prometheus:只取低基數子集,供趨勢與 burn-rate 判斷
    ASK_SLI_EVENTS_TOTAL.labels(
        route="/ask",
        sli_definition_version=event.sli_definition_version,
        sli_eligible=str(event.sli_eligible).lower(),
        sli_result=event.sli_result,
    ).inc()

    return event.to_response()

這段程式碼刻意把「建立 event」與「event 要送去哪裡」拆成兩個獨立步驟:build_ask_event() 產出包含所有欄位(含 request_id、prompt_version 等高基數資料)的完整物件,再分別餵給 logger.info()(全部欄位都留著)與 ASK_SLI_EVENTS_TOTAL.labels()(只挑出四個低基數欄位)。這個結構讓「哪些欄位進 metrics、哪些進 logs」變成程式碼裡一眼可見的事實,而不是散落各處、要靠讀過整個 codebase 才能拼湊出來的隱性規則。

光靠程式碼審查擋不住的意外:加一層執行期防呆

上面那段程式碼已經把「哪些欄位進 metrics」寫得很清楚,但這只在「寫程式碼的人記得遵守這個約定」的前提下有效。真實團隊會換人、會有人趕在deadline前臨時加一行程式碼、會有 code review 忙起來只掃過一眼就核准。前面提到的 user_id cardinality 爆炸案例,起點往往不是一次蓄意違規,而是某個人在某次緊急修 bug 時,臨時多加了一行 .labels(..., debug_user_id=user_id) 想方便自己當下排查,改完就忘記拔掉。code review 如果沒有特別留意,這種一行之差很容易被放行。

比較穩妥的做法,是在程式碼裡加一層執行期防呆,讓「意外把高基數欄位塞進 label」直接變成一個會被立刻發現的錯誤,而不是等三個月後 Prometheus 記憶體用量異常才回頭排查:

ALLOWED_SLI_RESULT_VALUES = {"good", "bad", "excluded"}
ALLOWED_ROUTE_VALUES = {"/ask"}


def record_sli_event(
    *,
    route: str,
    sli_definition_version: str,
    sli_eligible: bool,
    sli_result: str,
) -> None:
    """Only ever accepts values from a pre-declared, bounded set."""
    if route not in ALLOWED_ROUTE_VALUES:
        raise ValueError(f"unexpected route label value: {route!r}")
    if sli_result not in ALLOWED_SLI_RESULT_VALUES:
        raise ValueError(f"unexpected sli_result label value: {sli_result!r}")

    ASK_SLI_EVENTS_TOTAL.labels(
        route=route,
        sli_definition_version=sli_definition_version,
        sli_eligible=str(sli_eligible).lower(),
        sli_result=sli_result,
    ).inc()

這個函式故意只暴露一個限制過的介面,任何呼叫端想傳入 route 或 sli_result 以外的值,都會在應用程式自己的測試環境裡立刻拋出例外,而不是安靜地被 Prometheus 接受、變成一條新的 time series。sli_definition_version 沒有被同樣限制成一個固定集合,是刻意的:這個欄位本來就預期會隨版本更新而增加新值(v1、v2……),限制它反而會讓每次版本升級都要先改這個白名單;但它的成長速度是「每次規則變更才增加一個」,跟 user_id「每個使用者一個」的成長速度完全不同量級,所以留著不設限仍然安全。這個區分——同樣是「不設硬編碼上限的欄位」,成長速度和成長機制決定了它是安全還是危險——比單純記一句「不要放高基數欄位」更貼近實務判斷需要的細緻度。

event schema 演進:加欄位安全,改欄位意義要走版本

AskEvent 這份 event 定義不會停在今天的樣子。半年後,團隊可能需要新增一個欄位記錄「這次 retrieval 用了幾個文件」,或者把 workflow_status 拆得更細。這裡有一個經常被低估的原則:新增欄位與改變既有欄位的意義是完全不同等級的變更,前者通常安全,後者幾乎一定需要走過本文第 ⑤ 段的版本流程。

安全的演進(不需要改 sli_definition_version)
+ 新增一個選填欄位(例如 retrieved_document_count)
+ 新增一個此前不存在的 workflow_status 值,但先歸類為 bad(保守預設)
+ 為 log 增加除錯用的欄位,不影響任何 metric label

需要走版本流程的演進(必須改 sli_definition_version)
+ 修改既有欄位的可能值集合的「意義」(例如把某個 workflow_status
  從 bad 改判為 good,如第 ⑤ 段 degraded_completed 案例)
+ 收緊或放寬 latency 門檻
+ 改變分母的篩選條件(例如新增或移除一種排除類別)

分辨的關鍵不是「這個變更牽動了幾行程式碼」,而是「這個變更會不會讓某一筆過去被判定為 good 的請求,換到新規則下變成 bad(或反過來)」。新增欄位、新增一個保守預設為 bad 的狀態值,都不會讓既有請求的判定結果改變,可以放心地隨版本自然演進;但任何會讓過去和未來的判定結果不一致的變更,都必須被記錄成一次明確的版本升級,否則就會落回本文第 ①、⑤ 段一路警告的「SLI 隨 incident 悄悄變動」陷阱——只是這次觸發變動的不是一次 incident 後的臨時調整,而是一次看似無害的欄位重構。

④ 把文字規格做成一個可測試 classifier

如果 good event 只存在於會議紀錄,半年後沒有人知道某個 label 是誰定的。

先用標準函式庫把規則寫成小函式。這段程式不依賴特定監控套件,方便先對 fixture 討論行為。

from dataclasses import dataclass


GOOD_WORKFLOW_STATUSES = {
    "completed",
    "insufficient_context",
}


@dataclass(frozen=True)
class AskEvent:
    valid_request: bool
    workflow_status: str
    response_contract_valid: bool
    latency_ms: int | None
    system_rejection: bool = False


def is_sli_eligible(event: AskEvent) -> bool:
    """Only product-meaningful /ask requests enter this availability SLI."""
    return event.valid_request


def is_good_event(event: AskEvent, latency_limit_ms: int = 10_000) -> bool:
    if not is_sli_eligible(event):
        return False

    if event.system_rejection:
        return False

    if event.latency_ms is None or event.latency_ms > latency_limit_ms:
        return False

    if not event.response_contract_valid:
        return False

    return event.workflow_status in GOOD_WORKFLOW_STATUSES

這份 classifier 有幾個刻意不做的判斷。

它沒有檢查答案是否為真。

它也沒有把 requires_human_review 預設視為 good event。

前者屬於 Day 7 之後的 evaluation 與人工審查;後者要看「使用者是否在 SLO window 內得到可行下一步」是不是這個 API 的契約。

把尚未決定的事情寫成 UNKNOWN 或留在 spec 的待決欄位,比自己替產品承諾一個答案可靠。

接著建立 fixture。每一筆都要能看懂自己為什麼在分子或分母裡。

CASES = {
    "grounded_answer": AskEvent(
        valid_request=True,
        workflow_status="completed",
        response_contract_valid=True,
        latency_ms=820,
    ),
    "honest_unknown": AskEvent(
        valid_request=True,
        workflow_status="insufficient_context",
        response_contract_valid=True,
        latency_ms=640,
    ),
    "provider_timeout": AskEvent(
        valid_request=True,
        workflow_status="timed_out",
        response_contract_valid=False,
        latency_ms=10_001,
    ),
    "http_200_but_invalid": AskEvent(
        valid_request=True,
        workflow_status="completed",
        response_contract_valid=False,
        latency_ms=510,
    ),
    "malformed_payload": AskEvent(
        valid_request=False,
        workflow_status="invalid_request",
        response_contract_valid=False,
        latency_ms=0,
    ),
    "system_denied_user": AskEvent(
        valid_request=True,
        workflow_status="authorization_error",
        response_contract_valid=False,
        latency_ms=220,
        system_rejection=True,
    ),
}


for name, event in CASES.items():
    print(
        name,
        {
            "eligible": is_sli_eligible(event),
            "good": is_good_event(event),
        },
    )

預期判讀如下。

fixture eligible good 原因
grounded_answer True True 合法請求、契約完整、在門檻內完成
honest_unknown True True 產品允許誠實拒答,且回應格式完整
provider_timeout True False 使用者有有效需求,服務未在門檻內交付
http_200_but_invalid True False transport 成功不等於 response contract 成功
malformed_payload False False 此範例將無效請求排除於 availability 分母
system_denied_user True False 系統造成的拒絕應被看見

這是規格測試,不是 production 測試。

真正上線前,還要由產品 owner 確認「資料不足」和「人工審查」是否真的符合使用者可接受結果。

為什麼要為「還沒發生的情況」先寫 fixture

上面六個 fixture 都對應已經在 event contract 裡出現過的狀態。但一個 classifier 真正的價值,往往要等到系統演化出新狀態時才顯現。假設三個月後,工程團隊在 provider 層加了一個 fallback:當主要模型逾時,系統自動切換到次要模型完成回答,workflow_status 因此多了一個新值 degraded_completed。這時候,若沒有先寫好的 classifier 與 fixture,這個新狀態會安靜地落進 is_good_event() 的最後一行判斷式:

return event.workflow_status in GOOD_WORKFLOW_STATUSES

degraded_completed 不在 GOOD_WORKFLOW_STATUSES 集合裡,所以會被判成 bad——這個結果究竟是對是錯,取決於產品要不要把「降級後仍完成」算進使用者可接受的結果。無論哪個答案,重點是:這個決策應該以一則新增的 fixture 加上一行測試斷言的形式被明確記錄下來,而不是讓工程師憑當下的直覺,在合併程式碼時順手把它塞進某個集合裡。

CASES["degraded_but_completed"] = AskEvent(
    valid_request=True,
    workflow_status="degraded_completed",
    response_contract_valid=True,
    latency_ms=1_450,
)
# 決策記錄:2026-Q4 產品審查後,降級回答仍視為 bad event,
# 因為使用者體驗的模型能力與主要模型有落差,
# 需要先由 evaluation pipeline 驗證降級模型品質後再重新考慮。

這種「新狀態出現時先補 fixture 再改程式碼」的紀律,正是抵抗第 ① 段提到的「SLI 隨 incident 悄悄變動」的具體做法。當有人在事故後想要「順手把某個失敗狀態挪出分母」,這個改動必須先通過一則新增或修改的 fixture,讓 code review 能夠直接看到「這次改動讓哪個具體情境從 bad 變成 good」,而不是被埋在一行不起眼的 diff 裡。

邊界值:剛好卡在門檻上的那一筆請求,算 good 還是 bad?

六個 fixture 涵蓋的情境都離門檻有一段距離——820ms 明顯在 10 秒內、10,001ms 明顯超過。但真正容易讓程式碼與規格認知不一致的地方,往往藏在「剛好等於門檻」的那一筆請求。回頭看 is_good_event() 的判斷式:

if event.latency_ms is None or event.latency_ms > latency_limit_ms:
    return False

這裡用的是 >(大於),不是 >=(大於等於),代表一筆延遲剛好 10_000ms 的請求,會通過這一行判斷式繼續往下走,最終仍有機會被判成 good。這個選擇對不對,取決於 spec 裡「10 秒內完成」這句話原本想表達的是「小於 10 秒」還是「小於等於 10 秒」——這種語言上的模糊,恰恰是本文一路強調「文字規格必須落成可測試程式碼」的理由:只要停留在文字階段,沒有人會意識到這裡藏著一個二選一的實作決策;一旦寫成程式碼,這個決策無可迴避,而且會被永久固定下來,直到有人真的寫一個邊界值 fixture 才會重新被檢視。

CASES["exactly_at_latency_boundary"] = AskEvent(
    valid_request=True,
    workflow_status="completed",
    response_contract_valid=True,
    latency_ms=10_000,
)
# 決策記錄:10_000ms 剛好等於門檻,目前實作判定為 good
# (> 而非 >=)。若之後要改成「10 秒內」不含 10 秒整,
# 這裡是第一個要更新的 fixture。

這類邊界值測試常被視為「吹毛求疵」而跳過,但對 SLI 而言,它的價值不在於這一筆邊界請求本身有多重要,而在於它強迫團隊把一句口語化的規格(「10 秒內完成」)翻譯成一個明確、無歧義的程式碼行為,並且把這個翻譯結果用一筆可執行的測試永久記錄下來。半年後如果有人想把門檻從 10 秒改成 8 秒,這筆邊界值 fixture 會立刻告訴他:「這裡原本的判斷方式是這樣,你確定新門檻的邊界行為也要一樣嗎?」

把 fixture 字典換成 pytest 參數化測試

前面的 CASES 字典配合 for 迴圈印出結果,對「先討論規則本身合不合理」這個目的已經足夠——這正是它在文章前段刻意保持成一個可以直接貼進 REPL 跑的獨立腳本、不依賴任何測試框架的原因。但一旦這份 classifier 真的要進版控、接受 CI 檢查,逐字印出結果再靠人眼比對就不夠可靠了:人眼比對容易漏看一行、也不會在 pull request 裡自動擋下錯誤的變更。這時候把同一組 fixture 改寫成 pytest.mark.parametrize,讓每一筆案例變成一條獨立、有名字、會在 CI 失敗時精確報出是哪一筆壞掉的斷言,是很自然的下一步:

import pytest

from app.slo_spec import AskEvent, is_good_event, is_sli_eligible


@pytest.mark.parametrize(
    "name, event, expected_eligible, expected_good",
    [
        (
            "grounded_answer",
            AskEvent(True, "completed", True, 820),
            True,
            True,
        ),
        (
            "honest_unknown",
            AskEvent(True, "insufficient_context", True, 640),
            True,
            True,
        ),
        (
            "provider_timeout",
            AskEvent(True, "timed_out", False, 10_001),
            True,
            False,
        ),
        (
            "http_200_but_invalid",
            AskEvent(True, "completed", False, 510),
            True,
            False,
        ),
        (
            "malformed_payload",
            AskEvent(False, "invalid_request", False, 0),
            False,
            False,
        ),
        (
            "system_denied_user",
            AskEvent(True, "authorization_error", False, 220, system_rejection=True),
            True,
            False,
        ),
        (
            "exactly_at_latency_boundary",
            AskEvent(True, "completed", True, 10_000),
            True,
            True,
        ),
    ],
)
def test_sli_classification(name, event, expected_eligible, expected_good):
    assert is_sli_eligible(event) == expected_eligible, name
    assert is_good_event(event) == expected_good, name

這個改寫本身不改變任何分類邏輯,純粹是把「規則」從一段會被執行、但失敗時只會印出一堆文字的腳本,變成一組會被 CI 個別追蹤、個別報告失敗原因的斷言。好處在 code review 時特別明顯:如果有人改動 GOOD_WORKFLOW_STATUSES 想加入新狀態,CI 會精確指出「test_sli_classification[某個 fixture 名稱] 失敗」,而不是要 reviewer 自己重新跑一次腳本、肉眼比對哪一行印出的結果跟預期不符。這正是把第 ④ 段「文字規格必須落成可測試程式碼」這句話,再往前推一步:規則落成程式碼還不夠,程式碼還要落成 CI 會主動盯著的斷言,才能真正防止「新狀態悄悄落進錯誤分類」這種第 ④ 段已經討論過的風險。

⑤ SLI 至少要回答五個問題

只有 good / total 還不夠。

每個 SLI 規格至少應有下列欄位。

問題 範例答案 沒寫會發生什麼
使用者旅程是什麼? 已登入使用者取得政策問答的明確結果 把健康檢查或背景任務混進來
分母是什麼? valid_request=true 的 /ask completion event 無效流量或取消行為任意改變數字
分子是什麼? 10 秒內的 sli_result=good HTTP 200 或空 response 被誤當成功
資料從哪裡來? app counter;必要時以 completion log 交叉核對 同一件事在兩張 dashboard 有兩個答案
誰能改規則? service owner 與產品 owner,變更需 review incident 後偷偷改分母救數字

為什麼是這五個問題,而不是更少

這五個問題乍看只是把前面幾段的內容整理成表格,但值得說明為什麼剛好是這五個、少一個會出什麼問題。

「使用者旅程是什麼」排在第一位,是因為它決定了後面所有欄位的範圍。如果這個問題沒有先回答清楚,團隊很容易陷入一種常見的失序:先寫好 PromQL 查詢、湊出一個看起來合理的百分比,再回頭幫這個數字編一個「它大概在量什麼」的說法。這個順序一旦顛倒,SLI 就會變成「我們手上剛好有的資料能算出什麼」,而不是「使用者真正在意的旅程需要量測什麼」——前者是資料驅動的假象,後者才是真正以使用者為中心的量測。

「分母是什麼」與「分子是什麼」必須分開列成兩題,而不是合併成一個「good/total 怎麼算」,是因為這兩者常常各自出錯、卻用完全不同的方式出錯。分母錯了,通常是把不該算進來的流量算了進去(例如自動化探測、內部測試流量),讓比例被稀釋或膨脹;分子錯了,通常是把不該算成功的事件算成功(例如 HTTP 200 但 response 是空的)。分開列出來,才能在 review 時逐一檢查兩邊各自的定義是否站得住腳,而不是籠統地問「這個數字對不對」。

「資料從哪裡來」容易被當成技術細節省略,但它其實是「這個 SLI 能不能被信任」的關鍵欄位。第 ⑥ 段會談到,同一個 completion event 理論上可以用 metric counter 算,也可以用 log 查詢算,兩者如果對同一段時間給出不同答案,代表某個環節(通常是 label cardinality 被丟棄、或是多副本聚合方式不一致)出了問題。如果規格裡沒有明確寫「以哪一種資料源為準」,遇到數字對不上時,團隊會花大量時間爭論「該相信哪一份」,而不是去查真正的根因。

「誰能改規則」是最常被視為形式主義、卻在事後最關鍵的一欄。本文第 ⑧ 段會具體講到一起「事後偷改定義來湊達標」的真實案例;那次事件之所以造成信任危機,根本原因就是規則變更沒有經過任何 review、也沒有留下紀錄。把「誰有權改」寫進規格,不是不信任團隊成員,而是確保「規則變了」這件事本身,永遠可以在事後被追溯到是誰、什麼時候、為什麼改的。

另一個容易被省略的欄位是版本。

當 response contract 從 v1 改到 v2,或 provider fallback 新增 degraded_completed,不要偷偷改掉舊 classifier 的意思。

可以在規格中留下:

sli_definition_version: ask-availability-v1
effective_from: 2026-09-21
change_reason: initial Lab definition; no production target declared
review_owner: <team or named role>

版本不是官僚流程。

它讓 incident review 能回答:「這個月的 99.6% 是用哪個定義算出來的?」

版本號怎麼落地:一個具體的變更範例

抽象地說「要記版本」容易,實際遇到變更時怎麼做,值得走一遍完整流程。假設六個月後,團隊決定把 degraded_completed(上一段提到的降級回答)從 bad event 改判為 good event,因為 evaluation pipeline 已經證明降級模型的答案品質可接受。這個改動至少要同時發生四件事:

1. classifier 程式碼變更
   GOOD_WORKFLOW_STATUSES 加入 "degraded_completed"

2. sli_definition_version 從 ask-availability-v1 → ask-availability-v2
   (分子的定義變了,就不是同一個 SLI)

3. Prometheus label 帶上新版本號
   ask_sli_events_total{..., sli_definition_version="v2", ...}
   舊版本的 time series 不會被覆寫,v1 與 v2 在圖表上並存

4. spec 文件的 change log 補一行
   2026-Q4:degraded_completed 由 bad 改判 good,
   原因:evaluation pipeline 驗證降級模型品質達可接受標準(連結報告)
   核准人:<service owner> / <product owner>

第 3 點特別重要,也是最容易被省略的一步。如果只是讓同一個 sli_result label 在同一天悄悄開始把更多事件算成 good,Grafana 上的 availability 曲線會在改版當天出現一個台階式的跳升,但沒有任何標記告訴看圖的人「這一天發生了定義變更,不是系統真的變可靠了」。把版本號做成獨立 label,讓 v1 與 v2 的曲線並存、可以疊圖比較,才能誠實地回答「這次上升是系統變好、還是尺變鬆了」——這也是為什麼前面所有 PromQL 查詢都刻意把 sli_definition_version 寫進 label matcher,而不是只篩 route 跟 sli_result。

把這條 SLI 假想拉到一年後回顧,change log 累積起來會長成這樣一張表——這也是「誰能改規則」這一欄實際運作起來的樣子,而不是只存在文件裡的一句宣示:

版本 生效日 變更內容 核准人 觸發原因
ask-availability-v1 2026-09-21 初版 Lab 定義,非 production 承諾 — 本文示範起點
ask-availability-v2 2026-Q4 degraded_completed 由 bad 改判 good service owner / product owner evaluation pipeline 驗證降級模型品質達標
ask-availability-v3(假設) 未來 高風險轉真人旅程獨立拆出,不再併入本 SLI 分母 service owner / product owner / 法遵 旅程 C 流量成長,稀釋問題浮現

這張表本身就是本文第 ①、⑤ 段一路在強調的「可審查規格」的具體樣貌:任何人不需要去問任何人,只要打開這份 change log,就能重建「這個月的數字是用哪個定義算出來的」,以及「這個定義為什麼會變成現在這樣」。沒有這張表,同樣的知識只會存在於某幾個資深工程師的記憶裡,人一離職,這段歷史就跟著消失。

下集預告

定義寫清楚了,接下來要把它落地:從 completion event 匯總成 Prometheus counter、算出 window 與 error budget、設計 multiwindow burn-rate alert,再走一輪可重跑的 DIY。下篇(Day 14 下)接著講。


這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 13(下)|SPOF、Redundancy、Failover:備援不是多開一台就結束
下一篇
Day 14(下)|SLI、SLO、SLA:先量承諾,再談百分比
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言